本日進度 Day15
鐵人賽30天感覺我大概拆了15天,使用Angular的裝飾器來直接操作 DOM 元素
因為SVG的 裡面不能放 ng container,也不能放自訂義的元件,所以跟裝飾器搏鬥了一下
先來看一下成果,真的是乾淨優雅多了
<app-back2-home></app-back2-home>
<div style="width: 100%; height: 80vh;">
<svg #floorPlan width="100%" height="100%" viewBox="0 0 500 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<g id="room-outline">
<rect x="50" y="50" width="400" height="300" fill="none" stroke="black" stroke-width="2" />
<path d="M50 300 A 50 50 0 0 1 100 350" fill="none" stroke="black" stroke-width="2" />
<line x1="250" y1="50" x2="350" y2="50" stroke="black" stroke-width="2" />
</g>
</defs>
<use href="#room-outline" />
<g *ngFor="let furniture of furnitureList" [appFurniture]="furniture"
(dblclick)="handleDoubleClick(furniture,$event)" (updatePosition)="updateFurniturePosition($event)"
(updateRotation)="updateFurnitureRotation($event)" (updateSize)="updateFurnitureSize($event)">
</g>
</svg>
</div>
<app-popover [isVisible]="isPopoverVisible" [x]="popoverX" [y]="popoverY" (close)="onPopoverClose()"
(buttonClick)="onPopoverButtonClick($event)">
</app-popover>
import { Component, ElementRef, ViewChild } from '@angular/core';
import { NzModalService } from 'ng-zorro-antd/modal';
import { EditType } from 'src/app/feature/enum/furniture.enum';
import { Furniture } from 'src/app/feature/interface/furniture.interface';
import { FurnitureModalComponent } from 'src/app/feature/modal/furniture-modal/furniture-modal.component';
@Component({
selector: 'app-day15',
templateUrl: './day15.component.html',
styleUrls: ['./day15.component.scss']
})
export class Day15Component {
@ViewChild('floorPlan') floorPlanRef!: ElementRef<SVGSVGElement>;
@ViewChild('popover') popover!: ElementRef;
editType: EditType = EditType.furniture;
EditType = EditType;
isPopoverVisible = false;
furnitureSetting!: Furniture;
popoverX = 0;
popoverY = 0;
furnitureList: Furniture[] = [
{
id: '0', type: '沙發', x: 100, y: 100, width: 120, height: 60, color: 'lightblue', rotation: 0
},
{
id: '1', type: '桌子', x: 300, y: 200, width: 80, height: 80, color: 'brown', rotation: 0
},
{
id: '2', type: '床', x: 300, y: 70, width: 150, height: 80, color: 'beige', rotation: 0
},
{
id: '3', type: '書櫃', x: 51, y: 51, width: 40, height: 100, color: 'burlywood', rotation: 0
}
,
{
id: '4', type: '椅子', x: 250, y: 225, width: 40, height: 40, color: 'green', rotation: 0
}
];
constructor(private modal: NzModalService) { }
handleDoubleClick(item: Furniture, event: MouseEvent): void {
this.isPopoverVisible = true;
this.popoverX = event.clientX;
this.popoverY = event.clientY;
this.furnitureSetting = item;
}
onPopoverClose() {
this.isPopoverVisible = false;
}
onPopoverButtonClick(editType: EditType) {
switch (editType) {
case EditType.furniture:
this.isPopoverVisible = false;
this.showPopup(this.furnitureSetting);
break;
default:
break;
}
}
updateFurniturePosition($event: Furniture) {
}
updateFurnitureRotation($event: Furniture) {
}
updateFurnitureSize($event: Furniture) {
}
private showPopup(item: Furniture): void {
console.log('showPopup', item);
this.modal.create({
// nzTitle: tplTitle,
nzContent: FurnitureModalComponent,
nzFooter: null,
nzMaskClosable: false,
nzClosable: false,
// nzComponentParams: {
// // value: 'Template Context'
// },
nzOnOk: () => console.log('Click ok')
});
}
}
裝飾器通過 ElementRef
獲得對它所附加元素的直接引用,使得裝飾器可以直接訪問和操作這個元素。
之後透過Renderer2
服務來安全地操作 DOM。
昨天拆不出來是因為 offset 設定的關係,後來多加一個 initialFurniturePosition 來處理,還有在onChange 的時候 updateFurnitureTransform(),原理還沒搞得很明白,總之是會動了
感覺還可以優化的方向有Renderer2
跟@HostListener
import { AfterViewInit, Directive, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
import { Furniture } from '../interface/furniture.interface';
import { fromEvent, Subject, takeUntil } from 'rxjs';
@Directive({
selector: '[appFurniture]'
})
export class FurnitureDirective implements OnInit, OnChanges, AfterViewInit {
@Input('appFurniture') furniture!: Furniture;
@Output() updatePosition = new EventEmitter<Furniture>();
@Output() updateRotation = new EventEmitter<Furniture>();
@Output() updateSize = new EventEmitter<Furniture>();
private interactionState: 'idle' | 'dragging' | 'resizing' | 'rotating' = 'idle';
private destroy$ = new Subject<void>();
private offset = { x: 0, y: 0 };
private initialFurniturePosition = { x: 0, y: 0 };
constructor(
private el: ElementRef,
private renderer: Renderer2) { }
ngOnInit() {
this.createFurniture();
}
ngAfterViewInit() {
this.setupEventListeners();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['furniture'] && !changes['furniture'].firstChange) {
this.updateFurnitureTransform();
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
private createFurniture() {
const fragment = document.createDocumentFragment();
const svgNS = "http://www.w3.org/2000/svg";
const g = document.createElementNS(svgNS, 'g');
g.classList.add('furniture');
g.setAttribute('id', this.furniture.id);
const rect = this.createSVGElement('rect', {
width: this.furniture.width.toString(),
height: this.furniture.height.toString(),
fill: this.furniture.color,
stroke: 'black'
});
const text = this.createSVGElement('text', {
x: (this.furniture.width / 2).toString(),
y: (this.furniture.height / 2).toString(),
'text-anchor': 'middle',
'dominant-baseline': 'middle',
fill: 'black'
});
text.textContent = this.furniture.type;
const resizeHandle = this.createSVGElement('circle', {
cx: this.furniture.width.toString(),
cy: this.furniture.height.toString(),
r: '5',
class: 'resize-handle'
});
const rotateHandle = this.createSVGElement('circle', {
cx: (this.furniture.width / 2).toString(),
cy: '-20',
r: '5',
fill: 'red',
class: 'rotate-handle'
});
g.append(rect, text, resizeHandle, rotateHandle);
this.updateFurnitureTransform();
fragment.appendChild(g);
this.renderer.appendChild(this.el.nativeElement, fragment);
}
private createSVGElement(type: string, attributes: { [key: string]: string }): SVGElement {
const element = document.createElementNS("http://www.w3.org/2000/svg", type);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
return element;
}
private updateFurnitureTransform() {
const element = this.el.nativeElement as SVGGElement;
const centerX = this.furniture.width / 2;
const centerY = this.furniture.height / 2;
element.style.transform = `translate(${this.furniture.x}px,${this.furniture.y}px) rotate(${this.furniture.rotation}deg)`;
element.style.transformOrigin = `${centerX}px ${centerY}px`;
}
private updateFurnitureElement() {
const element = this.el.nativeElement as SVGGElement;
const rect = element.querySelector('rect') as SVGRectElement;
const text = element.querySelector('text') as SVGTextElement;
const resizeHandle = element.querySelector('.resize-handle') as SVGCircleElement;
const rotateHandle = element.querySelector('.rotate-handle') as SVGCircleElement;
rect.setAttribute('width', this.furniture.width.toString());
rect.setAttribute('height', this.furniture.height.toString());
text.setAttribute('x', (this.furniture.width / 2).toString());
text.setAttribute('y', (this.furniture.height / 2).toString());
resizeHandle.setAttribute('cx', this.furniture.width.toString());
resizeHandle.setAttribute('cy', this.furniture.height.toString());
rotateHandle.setAttribute('cx', (this.furniture.width / 2).toString());
this.updateFurnitureTransform();
}
private setupEventListeners() {
fromEvent<PointerEvent>(this.el.nativeElement, 'pointerdown')
.pipe(takeUntil(this.destroy$))
.subscribe(this.handlePointerDown.bind(this));
fromEvent<PointerEvent>(document, 'pointermove')
.pipe(takeUntil(this.destroy$))
.subscribe(this.handlePointerMove.bind(this));
fromEvent<PointerEvent>(document, 'pointerup')
.pipe(takeUntil(this.destroy$))
.subscribe(this.handlePointerUp.bind(this));
}
private handlePointerDown(event: PointerEvent) {
const target = event.target as SVGElement;
if (target.classList.contains('resize-handle')) {
this.startResize(event);
} else if (target.classList.contains('rotate-handle')) {
this.startRotating(event);
} else {
this.startDrag(event);
}
}
private handlePointerMove(event: PointerEvent) {
if (this.interactionState !== 'idle') {
event.preventDefault();
const svgPoint = this.getSVGPoint(event);
requestAnimationFrame(() => {
switch (this.interactionState) {
case 'dragging':
this.drag(svgPoint);
break;
case 'resizing':
this.resize(event);
break;
case 'rotating':
this.rotate(event);
break;
}
});
}
}
private handlePointerUp() {
this.interactionState = 'idle';
}
private startDrag(event: PointerEvent) {
this.interactionState = 'dragging';
const svgPoint = this.getSVGPoint(event);
this.initialFurniturePosition = { x: this.furniture.x, y: this.furniture.y };
this.offset = {
x: svgPoint.x - this.initialFurniturePosition.x,
y: svgPoint.y - this.initialFurniturePosition.y
};
}
private drag(point: DOMPoint) {
if (this.furniture) {
console.log(point);
this.furniture.x = point.x - this.offset.x;
this.furniture.y = point.y - this.offset.y;
this.updateFurnitureTransform();
this.updatePosition.emit(this.furniture)
}
}
private startResize(event: PointerEvent) {
event.stopPropagation();
this.interactionState = 'resizing';
if (this.furniture) {
const svgPoint = this.getSVGPoint(event);
this.offset = {
x: svgPoint.x - this.furniture.x - this.furniture.width,
y: svgPoint.y - this.furniture.y - this.furniture.height
};
}
}
private resize(event: PointerEvent) {
const svgPoint = this.getSVGPoint(event);
const newWidth = Math.max(20, svgPoint.x - this.furniture.x);
const newHeight = Math.max(20, svgPoint.y - this.furniture.y);
this.furniture.width = newWidth;
this.furniture.height = newHeight;
this.updateFurnitureElement();
}
private startRotating(event: PointerEvent) {
this.interactionState = 'rotating';
if (this.furniture) {
const angle = this.getAngle(event, this.furniture);
this.offset.x = angle - this.furniture.rotation;
}
}
private rotate(event: PointerEvent) {
if (this.el.nativeElement && this.furniture) {
const angle = this.getAngle(event, this.furniture);
this.furniture.rotation = angle - this.offset.x;
this.updateFurnitureTransform();
}
}
private getAngle(event: PointerEvent, furniture: Furniture): number {
const element = this.el.nativeElement as SVGGElement;
const rect = element!.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return Math.atan2(event.clientY - centerY, event.clientX - centerX) * (180 / Math.PI);
}
private getSVGPoint(event: PointerEvent): DOMPoint {
const svg = this.el.nativeElement.closest('svg') as SVGSVGElement;
const pt = svg.createSVGPoint();
pt.x = event.clientX;
pt.y = event.clientY;
return pt.matrixTransform(svg.getScreenCTM()!.inverse());
}
}